线程同步之synchronized

synchronized是一个Java的关键字,在线程同步上发挥着重要作用。熟悉synchronized关键字的用法及原理有助于我们编写并发代码。

What

synchronized关键字用于修饰一“块”代码,这块代码可以是一个方法,也可以是方法中的一段代码,进入这块代码的线程相当于拥有了一把“锁”,其他线程运行到这一行时只能等待锁的释放才能去竞争锁、进入代码块执行,从而避免了多个线程对相同变量的访问,保证了各线程的同步。

Why

考虑如下场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Test {
int x = 0;
public void inc() {
x++;
}
}
// 测试代码
Test test = new Test();
new Thread(()->{
for(int i=0;i<10000;i++)
test.inc();
}).start();
new Thread(()->{
for(int i=0;i<10000;i++)
test.inc();
}).start();
new Thread(()->{
for(int i=0;i<10000;i++)
test.inc();
}).start();

等三个线程均执行完毕后,test.x的值是多少?答案显然不一定是30000,可能是[10000,30000]的某个值。
由于三个线程同时访问x变量,而每个线程中x的自增操作会分解为三步:

  1. 从主内存中将x的值读取到该线程的工作内存中。
  2. 计算x自增的值。
  3. 将x自增的值写回工作内存及主内存。

而由于这三步中存在着时间差,可能导致以下情况:

  1. x = 0。
  2. T1从主内存中读取x的值到其工作内存。
  3. T1计算x的新值为1。
  4. T2从主内存中读取x的值到其工作内存,此时T2读到的x为旧值0。
  5. T2计算x的新值仍为1。
  6. T1写x的新值1到工作内存及主内存。
  7. T2写x的新值1到工作内存及主内存。
  8. T1和T2一共经过了两次循环,x的值却只增加了1。

How-To

synchronized关键字可以用来修饰普通方法、静态方法及代码块。

普通方法

当修饰类普通方法时,锁只会加在访问同一个对象该方法的那些线程上,如上例中如果inc方法用synchronized关键字修饰,那么所有访问test对象inc方法的线程共用一把锁。其他线程如果访问其他对象的inc方法,使用的是另外的锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class Test {
int x = 0;
public synchronized void inc() {
x++;
}
}
// 测试代码
Test test = new Test(), test2 = new Test();
Thread t1 = new Thread(()->{
for(int i=0;i<10000;i++)
test.inc();
});
Thread t2 = new Thread(()->{
for(int i=0;i<10000;i++)
test.inc();
});
Thread t3 = new Thread(()->{
for(int i=0;i<10000;i++)
test2.inc();
});
Thread t4 = new Thread(()->{
for(int i=0;i<10000;i++)
test2.inc();
});
t1.start();t2.start();t3.start();t4.start();
// t1, t2共享test.inc()的锁,t3, t4共享test2.inc()的锁

静态方法

当修饰静态方法时,由于静态方法独立于对象存在,因此所有访问该静态方法的线程共享一把锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class Test {
static int x = 0;
public static synchronized void inc() {
x++;
}
}
// 测试代码
Thread t1 = new Thread(()->{
for(int i=0;i<10000;i++)
Test.inc();
});
Thread t2 = new Thread(()->{
for(int i=0;i<10000;i++)
Test.inc();
});
Thread t3 = new Thread(()->{
for(int i=0;i<10000;i++)
Test.inc();
});
Thread t4 = new Thread(()->{
for(int i=0;i<10000;i++)
Test.inc();
});
t1.start();t2.start();t3.start();t4.start();
// 所有线程共享Test.inc()的锁

代码块

synchronized关键字还能修饰方法中的一段代码,这样做会比锁住整个方法更有效率,因为同时可以有多个线程进入到方法中,进行那些不需上锁的操作。

普通方法代码块

与修饰方法不同,当synchronized关键字修饰普通方法内的代码块时,“锁芯”可以是固定的this对象或类的Class对象,也可以更换为其他对象:几乎任意对象,但不能为null。
这是因为,“锁芯”对象应为所有访问该方法的线程均能访问的同一个对象,从而确保线程之间互斥。this对象、Class对象及其他final对象能够防止“锁芯”更换,从而避免各线程的锁不一致,因此“锁芯”可以为这三类对象。
当代码块范围为整个方法里的代码,且锁住的是this对象时,此锁与锁住整个方法等效。

1
2
3
4
5
6
7
private static int x = 0;
public void inc() {
synchronized (this) {
x++;
}
}
// 等价于public synchronized void inc() {x++;}

1
2
3
4
5
6
private static int x = 0;
public void inc() {
synchronized (Test.class) {
x++;
}
}
1
2
3
4
5
6
7
private final Object lock = new Object();
private static int x = 0;
public void inc() {
synchronized (lock) {
x++;
}
}
静态方法代码块

由于静态方法通常只由与类相关,而与该类的任何对象无关,因此“锁芯”通常由所有直接或间接调用该类静态方法(类名.方法)的线程共享。静态方法代码块与普通方法内代码块的“锁芯”区别在于,前者显然不能是this对象或任何非静态对象,只能是静态对象或类的Class对象。同样的道理,当使用静态对象作为“锁芯”时,为确保“锁芯”不变,通常需对其加final关键字。
当代码块范围为整个方法里的代码,且锁住的是当前类的Class对象时,此锁与锁住整个方法等效。

1
2
3
4
5
6
private static int x = 0;
public static void inc() {
synchronized (Test.class) {
x++;
} // 等价于public static synchronized void inc() {x++;}
}

1
2
3
4
5
6
7
private final static Object lock = new Object();
private final static int x = 0;
public static void inc() {
synchronized (lock) {
x++;
}
}
继承性

需要注意的是,synchronized修饰的方法在子类中是不会继承同步性的,即多个线程调用该子类继承父类的方法时是不会加锁的,除非重写并加上关键字。

How Exactly

可重入性

从一个对象的synchronized方法调用该对象的另一个synchronized方法时,调用的线程共享的只有一把对象锁:

1
2
3
4
5
6
7
synchronized void m1(String t) {
System.out.println(t + "m1");
m2(t);
}
synchronized void m2(String t) {
System.out.println(t + "m2");
}

即当调用同一个对象的m1方法或m2方法时,所有线程共享一把对象锁;进入该对象m1方法的线程,在进入m2时可以直接获取该锁,这就是可重入性。
可重入的代码不能使用静态或全局变量,同时不调用不可重用的代码。

可重入锁

synchronized关键字提供的是一个可重入锁。
需要注意的是方法实际执行的操作,如:当synchronized方法A调用某个方法B,而B没有上锁的同时可以被其他线程直接调用,就绕过了锁直接进行B方法中的操作,从而造成线程不安全。
显然,当调用不同的对象的synchronized方法时,这些线程不会共享对象锁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class SyncTest {
synchronized void m1(String t) {
System.out.println(t + "m1");
m2(t);
}
synchronized void m2(String t) {
System.out.println(t + "m2");
}

public static void main(String[] args) {
SyncTest t = new SyncTest();
SyncTest t2 = new SyncTest();
new Thread(()->{
t.m1(Thread.currentThread().getName());
}).start();
new Thread(()->{
t.m1(Thread.currentThread().getName());
}).start();
new Thread(()->{
t.m1(Thread.currentThread().getName());
}).start();
new Thread(()->{
t2.m1(Thread.currentThread().getName());
}).start();
}
}
/* 可能的情况
Thread-0m1
Thread-0m2
Thread-1m1
Thread-1m2
Thread-3m1 //另一把锁
Thread-3m2 //另一把锁
Thread-2m1
Thread-2m2
*/

Monitor

在JVM的堆中,每个对象占有一块空间,其中一部分称为对象头,对象头中有一组字段用于表示锁的状态,被称为Monitor,包含一个线程持有者和一个程序计数器,当计数器的值为0时该锁处于开启状态,因此线程先到先得。
当线程获取某对象的锁后,JVM记下该线程的信息,并将计数器的值加1。
当不是当前持有锁的线程访问该对象的synchronized方法或代码块,且计数器的值大于0时,该线程等待。
当锁的持有线程再次访问该对象的synchronized方法或代码块时,再次拿到同样的该锁(重入),并将计数器的值加1,因此计数器的值可以超过1。
每当线程退出一个synchronized方法或代码块时,计数器的值减1,直至减到0,此时该线程已将锁释放。